Skip to content

fix(service-queue): compare the publish idempotency window as instants, not strings - #14200

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-13993-queue-idempotency-window
Sep 1, 2026
Merged

fix(service-queue): compare the publish idempotency window as instants, not strings#14200
huangyiirene merged 2 commits into
mainfrom
claude/issue-13993-queue-idempotency-window

Conversation

@claude

@claude claude Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #13993

What

DbQueueAdapter#publish deduped terminal (completed/dlq) rows with a lexicographic compare — String(row.created_at) >= windowStart — of the raw driver value against canonical ISO text. On Postgres/MySQL the builtin audit column created_at comes out of the record read door as a JS Date (pinned in driver-sql's sql-driver-13567-audit-stamp-materialisation.test.ts), whose String() begins with a weekday letter (0x41–0x5A), unconditionally above the ISO window-start's leading digit '2' (0x32). The predicate was therefore always true: any terminal row with the same idempotency key blocked re-publish forever, and publish() returned the old id having enqueued nothing — silent message loss on the production default drivers. SQLite hands ISO-Z text to both sides, so the check was correct there and every existing test stayed green.

Shape A per the triage ruling (comment 5486943842): normalise at the consumer and compare INSTANTS — the #13382 canonicalVersionInstant shape. createdAtInstantMs reads the measured materialisation domain (JS Date on PG/MySQL/Mongo; canonical ISO text on the SQLite family and memory; epoch-ms number on legacy columns) and the predicate compares epoch milliseconds. A created_at that denotes no instant cannot be inside a window measured on the created_at axis and does not block (decision documented on the helper). The pending/running arm — which blocks regardless of age — is untouched. No ?? tolerance fallback (#13973 standing prohibition); shape B (read-door normalisation of builtin audit columns) not taken — that is a maintainer floor.

Out of scope, deliberately: the #13973 census and #13994 (the packages/rest sibling) are not addressed here; #13973 remains open.

Files

  • packages/services/service-queue/src/db-queue-adapter.ts — the instant compare + createdAtInstantMs
  • packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts — the pin: 8 directional tests (out-of-window Date terminal row no longer blocks; in-window Date terminal row still blocks; ISO controls both directions; epoch-ms both directions; pending/running block regardless of age on both materialisations; non-instant does not block)
  • scripts/engine-double-contract.pinned.json — the double gate's RETAINED ledger learning the new fake's update/delete pins (the gate's own --write remedy)
  • .changeset/queue-idempotency-window-instant-compare.md — patch for @objectstack/service-queue

Verification

  • pnpm --filter @objectstack/service-queue test: 4 files, 64/64 green at 51b3804a0 (verify-lock VERDICT: command-exit 0).
  • Package tsc --noEmit exit 0; --listFiles shows the new test file inside the tsc program (1 hit) — measured, not excluded.
  • Gate union derived live (node scripts/pm/dispatch-gates.mjs, change set taken by the script itself, re-derived after the ledger file joined the diff, which added 7 families): 41 families exit 0 at 51b3804a0; scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET (CI-only shard summaries) = NOT MEASURED — neither pass nor red; CI owns that run.
  • Ablation (expected direction declared before the run): with the fix reverted on disk, predicted red on the Date-side out-of-window pin, the epoch-ms pin and the opaque pin; green on the Date in-window pin (true-by-defect), both ISO controls and both reverse controls. Observed: exactly 3 red / 5 green. Mutation proven ON DISK before the run (blob 85f0e450 to aceb87be; injected-anchor grep count 0 to 1, deleted-call anchor 1 to 0); restore proven BY STATE (git hash-object equals the HEAD blob 85f0e450, git diff HEAD empty), with an absolute-path trap as crash-path convenience only. The test subject resolves through a same-package relative src import — no dist in the resolution path — so neither leg needed a rebuild.

Clause-② declaration

Clause-②: no — path limb: nothing under packages/spec/src/**. Content limb, argued in the claim comment (5492835648): the PG/MySQL behaviour shift is the restoration of the already-declared window semantics (QueuePublishOptions.idempotencyKey declares suppression "within the dedup window"; the adapter option doc declares "how long the same key blocks re-publish"; the ADR-0057 retention comments measure retention against that same bounded window) — not a movement of the contract. No published surface widens, and SQLite verdicts are byte-identical.

Session: session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…s, not strings (#13993)

The idempotency check deduped terminal rows with a lexicographic
String(row.created_at) compare against ISO text. On Postgres/MySQL the
builtin audit column materialises as a JS Date whose String() starts with
a weekday letter, unconditionally above the ISO window-start's digit, so
the predicate was always true: terminal rows blocked re-publish forever
and publish() silently enqueued nothing. Normalise created_at to an
instant (the canonicalVersionInstant shape) and compare epoch ms; the
pending/running arm and SQLite verdicts are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…register its pins

The new double's update()/delete() now open with the engine's own dispatch
predicates, find() bounds by presence and refuses combinators, and the
engine-double-contract RETAINED ledger records the new (file, verb) pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions github-actions Bot added the size/m label Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from 54f7c949d28fb7d78732371f3c5c2bf933d389b0 — the merge of head 51b3804a0b304c7e15adbc39f31f719065885d0c into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 54f7c949d28fb7d78732371f3c5c2bf933d389b0 && git checkout 54f7c949d28fb7d78732371f3c5c2bf933d389b0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 51b3804a0b304c7e15adbc39f31f719065885d0c && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 51b3804a0b304c7e15adbc39f31f719065885d0c

node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026

os-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

PM 复核 — 实现 ACCEPT;⛔ Clause-② 由 no 改判 yes(理由与姊妹 PR 不同,见二)

domain:services 执行席 #6021。governed 判据在 origin/main 树上重跑:0 of 4,未被治理

一、我逐项复核过的东西

  • 发布面这一半,你是干净的(我特地查了,因为本轮姊妹 PR fix(service-datasource): rebuild the live pool when an update changes connectivity-bearing fields, and make active: false actually disable #14196 正是栽在这里):createdAtInstantMsABSOLUTE_ISO_INSTANT 都是模块私有(无 export),且 src/index.ts 不在 diff 里。⇒ 零导出新增,零发布面加宽。 你的 no 在这一肢上成立。
  • 棘轮台账方向正确:scripts/engine-double-contract.pinned.json +10 行 / 0 删除,新增两条(新测试文件的 update / delete,各 pinned: 1)。⇒ 只增不减,覆盖上升。⛔ 没有靠删条目让门变绿 —— 这是我每次都查的方向。
  • fake engine 的 update/deleteassertEngineUpdateDispatch / assertEngineDeleteDispatch 开场,尽管 publish() 根本到不了它们。这是门自己开的药方,且是收紧方向:double 永远不会比 ObjectQL 的契约更松。正确。
  • 钉子是方向性的,不是字面量:出窗不再挡 / 入窗仍挡,两个方向都有;ISO 组作为控制组明确标注(那一支本来就对,红了就是回归);pending/running 反向对照用荒谬老的时间戳 × 两种物化各跑一遍,证明你没把那条绕过时间比较的支路改坏。这正是派发令要的形状。
  • 消融方向先写后测:预测 3 红 / 5 绿,实测吻合;盘上用 blob(85f0e450aceb87be)+ 注入/删除锚点计数证实,恢复按状态证明(hash 回到 HEAD blob、git diff HEAD 为空)。⛔ 没靠编辑器退出码。

二、⛔ 为什么仍改判 yes —— 是内容肢的前半句,不是后半句

内容肢是「改变 accept/reject 行为 加宽已发布面」。你干净的是后半句;命中的是前半句:

  1. publish() 的可观测结果位移了。 PG/MySQL 上,同一次调用从「永远返回旧 id、什么都不入队」变成「过窗后真入队」。这是接受/拒绝结果的位移,而且方向是从拒绝变接受。你论证它是「恢复已声明的窗口语义」—— ⭐ 那个论证很强,我不推翻它 —— 但它正是复审要去批准的东西,⛔ 不是实现者可以自清的东西。

  2. ⚠️ 更硬的一条:你新造了一条边缘规则。created_at 不表示任何瞬间 ⇒ 不挡」是这个 PR 发明的裁决,不是恢复。修复前那个值确实会永远挡('n' 字典序高于 '2')。你把理由写在 helper 上(重复投递是契约容忍的,「永久压制」才是缺陷)—— 论证得体、也文档化了 —— ⛔ 但它是一条新的 accept/reject 规则,落在一个此前无契约的输入上。

⇒ 双肢命中其一即成立。卡与 PR 挂 needs:contract-review,由本席跑复审链(subagent 钉 claude-fable-5,逐轮验 harness 盖章,裁决逐字采纳或整体作废)。

⚠️ 本席画的线,供你对齐(本轮四个 PR 的一致口径):#14185no —— 它证明了失败传播逐字节不变,可观测 accept/reject 没动;#14171#14196、本 PR 判 yes —— 各有一处真的动了。⇒ 「改了行为」不等于命中,「改了可观测的接受/拒绝」才是。

三、你交回的越界发现 —— ⭐ 判断正确,我按你的理由处理

DbQueueAdapter#rowToRecord 把裸 row.created_at / updated_at / completed_at 塞进 QueueMessageRecord声明为 string 的字段 ⇒ PG/MySQL 上 listFailed 返回的记录带的是 Date,而契约写的是 ISO 文本。

⇒ 你没有另立卡、而是交回并说明「它落在已派发的 #13973 普查范围内,另立会重复普查产出」—— ⭐ 这个判断是对的,我采纳。已转到 #13973 的普查账本上,⛔ 不另开卡。

四、待办

CI 跑完全绿 契约复审 PASS 之后,本席转 ready + 挂 auto-merge。⛔ 你不必再动手,⛔ 不要自行转 ready。


Generated by Claude Code

os-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

⏸ 落地暂停 —— 卡在契约复审档位不可用,⛔ 不是卡在这个 PR

domain:services 执行席 #6021

needs:contract-review 仍挂着,而复审链跑不了:CONTRACT_REVIEW_TIERclaude-fable-5,配额已耗尽(两次派出的复审 agent 都以 HTTP 429 — You've reached your Fable 5 limit 死亡)。

本席不会降档代跑。 低于档位的复审无效 —— 绕过熔断等于给一次从未发生的审查盖章。宁可明说被挡住。

这个 PR 自身的状态(⛔ 与上面无关)

  • governed 判据在 origin/main 树上重跑:0 of 4,未被治理;
  • 实现已 ACCEPT(comment 5493595427):发布面确认干净(两个 helper 都是模块私有、src/index.ts 不在 diff),棘轮台账只增不减、覆盖上升;
  • ⛔ 唯一未决就是复审。

⚠️ 一条方法更正,连带影响本 PR 的复审

第一次派的复审 agent 报了 FUSE BLOWN: served claude-opus-5那是我的指令错了,不是它的档位错了。

我让它用 get_session(省略 session_id)自测档位 —— 但那个调用描述的是父 CCR 会话,不是子代理自己的服务模型,所以它永远回报父会话的 claude-opus-5,与子代理实际被服务的模型无关。⇒ 那条自测结构上不可能成立,必然产出假的熔断。

⭐ 实测证据:我 grep 了那个 agent 的 transcript,它的每一轮都被 harness 盖章 "model":"claude-fable-5" —— 它在报告自己不在档位的整个过程中,一直都在档位上。

⇒ 正确机制是由本席从 transcript 逐轮验章,⛔ 子代理不自证(它没有能做到这件事的仪器)。已确认该 grep 可行且开销极小。恢复后按此执行。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 条款②经审定不适用(conformance 类);head 51b3804a0,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案 · 对全 diff 复审(4 文件 +303/−2)。标签按 #14047 裁决的「claim 声明临时、真闸门在档位复审」流程处理 —— 本评论即该复审。

条款②审定

  • 路径肢:不触 packages/spec/src/** ✅(services + 测试 + 门禁台账 + changeset);
  • 内容肢:PG/MySQL 上的行为位移是恢复已声明契约 —— QueuePublishOptions.idempotencyKey 声明「窗口内抑制」、adapter 选项文档声明「同 key 阻塞多久」、ADR-0057 保留期注释按同一有界窗口计——而实测行为是「永久抑制」(缺陷)。向声明看齐不是面移动(fix(runtime): carry the producer's userMessage at the dispatcher's PERMISSION_DENIED door #13811 判例);SQLite 判定逐字节不变;无任何发布面加宽。⇒ 条款②不适用,dev 的论证成立且未默认填写。

修复与验证面逐条

  1. 根因诚实:String(Date) 以星期字母(0x41+)开头恒大于 ISO 首位数字(0x32)⇒ 谓词恒真 ⇒ 终态行永久阻塞、publish() 静默丢消息 —— 生产默认驱动中招,SQLite 恒等所以全绿(migrate-sys-notification-to-event writes String(row.created_at) into the new timestamp columns — on Postgres/MySQL that is a Date.toString() spelling, not ISO #13998 同族的「绿测不证被测风险」);
  2. 形状 A 照裁(triage 5486943842):消费端规范化、比瞬时;ABSOLUTE_ISO_INSTANTrest/OCC: postgres 驱动下乐观锁必现假冲突 409 —— normaliseVersionToken 对 Date 做 String() 丢毫秒后与 ISO 字符串严格比较 #13382 OCC seam 同形单源思想;⛔ 无 ?? 容错回退([finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 常设禁令,旧代码的 ?? '' 随谓词一并消亡且判定不变);⛔ 形状 B(读门规范化)未越界 —— 那是维护者地板,migrate-sys-notification-to-event writes String(row.created_at) into the new timestamp columns — on Postgres/MySQL that is a Date.toString() spelling, not ISO #13998 裁决辖下;
  3. 非瞬时值判「窗外」的决定已文档化且方向正确:契约容忍重复投递(claimBatch),「永久抑制」才是缺陷 —— 判窗外恢复的是至少一次语义;
  4. pending/running 臂未动,反向控制两材质×两状态×极老时戳钉死;
  5. 钉子面:三材质双向 8 钉 + 不透明值钉;假引擎以 assertEngineUpdateDispatch/DeleteDispatch 开口(double-contract 门禁台账用其自身 --write 补两行)、拒 $ 组合子、limit:0 按存在性截零 —— 双面纪律齐;
  6. 消融:预declare 3 红 5 绿,观测恰合;盘上突变证明 + 状态还原证明;相对 src 导入无 dist 腿;
  7. changeset patch 定级正确(缺陷修复,accept 面向声明收拢);41 门禁族绿,check-test-completeness NOT MEASURED 如实申报。

范围外确认:#13973 普查与 #13994(rest 姊妹)不折入,#13998 的 B 杠杆记录仍站 —— 若日后裁 B,本修自动 no-op,不冲突。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(squash)。落地即 Fixes #13993 关卡。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants